FluentValidation
- FluentValidation - Comprehensive Practice Exercises
- Table of Contents
- Basic Validators
- Exercise 1: Create Your First Validator
- Exercise 2: Validate Manually
- Built-in Validation Rules
- Exercise 3: Explore Built-in Rules
- Custom Validation Rules
- Exercise 4: Create Custom Validators
- Async Validation
- Exercise 5: Implement Async Validators
- Dependent Rules (When/Unless)
- Exercise 6: Conditional Validation with When/Unless
- Nested Validators
- Exercise 7: Validate Complex Nested Objects
- Collection Validation
- Exercise 8: Validate Collections
- RuleSet Usage
- Exercise 9: Use RuleSets for Different Scenarios
- Integration with ASP.NET Core
- Exercise 10: Integrate FluentValidation with ASP.NET Core
- Advanced Rule Composition
- Exercise 11: CascadeMode Stop
- Exercise 12: Custom Must Rule
- Exercise 13: Cross-Property Validation
- Exercise 14: RuleForEach with Child Validator
- Exercise 15: Custom Property Validator
- Exercise 16: Async Uniqueness Check
- Exercise 17: DependentRules
- Exercise 18: When/Unless Conditions
- Exercise 19: RuleSets for Create vs Update
- Exercise 20: Include Other Validators
- Exercise 21: ValidationContext Root Data
- Exercise 22: Severity and Error Codes
- Exercise 23: Localized Messages
- Exercise 24: Polymorphic Validation
- Exercise 25: Validate Partial Updates
- Exercise 26: Unique Items in a Collection
- Exercise 27: Transform for Normalization
- Exercise 28: Custom Validation Block
- Exercise 29: MediatR Pipeline Integration
- Validator Testing & Diagnostics
- Exercise 30: Test Helper Usage
FluentValidation - Comprehensive Practice Exercises
Table of Contents
- Basic Validators
- Built-in Validation Rules
- Custom Validation Rules
- Async Validation
- Dependent Rules (When/Unless)
- Nested Validators
- Collection Validation
- RuleSet Usage
- Conditional Validation
- Integration with ASP.NET Core
- Error Message Customization
- Advanced Rule Composition
- Validator Testing & Diagnostics
---
Basic Validators
Exercise 1: Create Your First Validator
Question: Create a validator for a user registration request with basic validation rules.
Answer
// Application/DTOs/RegisterUserRequest.cs
public class RegisterUserRequest
{
public string Username { get; set; }
public string Email { get; set; }
public string Password { get; set; }
public string ConfirmPassword { get; set; }
public int Age { get; set; }
}
// Application/Validators/RegisterUserRequestValidator.cs
public class RegisterUserRequestValidator : AbstractValidator<RegisterUserRequest>
{
public RegisterUserRequestValidator()
{
RuleFor(x => x.Username)
.NotEmpty().WithMessage("Username is required")
.Length(3, 50).WithMessage("Username must be between 3 and 50 characters");
RuleFor(x => x.Email)
.NotEmpty().WithMessage("Email is required")
.EmailAddress().WithMessage("Invalid email format");
RuleFor(x => x.Password)
.NotEmpty().WithMessage("Password is required")
.MinimumLength(8).WithMessage("Password must be at least 8 characters");
RuleFor(x => x.ConfirmPassword)
.Equal(x => x.Password).WithMessage("Passwords must match");
RuleFor(x => x.Age)
.GreaterThanOrEqualTo(18).WithMessage("You must be at least 18 years old");
}
}
// Usage
public class UserService
{
private readonly IValidator<RegisterUserRequest> _validator;
public UserService(IValidator<RegisterUserRequest> validator)
{
_validator = validator;
}
public async Task<Result> RegisterUserAsync(RegisterUserRequest request)
{
// Validate
var validationResult = await _validator.ValidateAsync(request);
if (!validationResult.IsValid)
{
var errors = validationResult.Errors
.Select(e => e.ErrorMessage)
.ToList();
return Result.Failure(string.Join(", ", errors));
}
// Process registration
// ...
return Result.Success();
}
}
// Program.cs - Register validators
builder.Services.AddValidatorsFromAssemblyContaining<RegisterUserRequestValidator>();
---
Exercise 2: Validate Manually
Question: Demonstrate different ways to manually trigger validation.
Answer
public class ValidationExamples
{
private readonly IValidator<CreateProductRequest> _validator;
public ValidationExamples(IValidator<CreateProductRequest> validator)
{
_validator = validator;
}
// 1. Basic validation
public void BasicValidation(CreateProductRequest request)
{
var result = _validator.Validate(request);
if (!result.IsValid)
{
foreach (var error in result.Errors)
{
Console.WriteLine($"Property: {error.PropertyName}");
Console.WriteLine($"Error: {error.ErrorMessage}");
Console.WriteLine($"Attempted Value: {error.AttemptedValue}");
}
}
}
// 2. Async validation
public async Task AsyncValidation(CreateProductRequest request)
{
var result = await _validator.ValidateAsync(request);
if (!result.IsValid)
{
// Handle errors
}
}
// 3. Throw on failure
public void ValidateAndThrow(CreateProductRequest request)
{
try
{
_validator.ValidateAndThrow(request);
// Validation passed
}
catch (ValidationException ex)
{
// Validation failed
var errors = ex.Errors;
}
}
// 4. Validate specific properties
public void ValidateSpecificProperty(CreateProductRequest request)
{
var result = _validator.Validate(request, options =>
{
options.IncludeProperties(x => x.Name);
options.IncludeProperties(x => x.Price);
});
}
// 5. Validate using RuleSet
public void ValidateWithRuleSet(CreateProductRequest request)
{
var result = _validator.Validate(request, options =>
{
options.IncludeRuleSets("Create");
});
}
// 6. Get specific validation result details
public void GetValidationDetails(CreateProductRequest request)
{
var result = _validator.Validate(request);
// Check if valid
bool isValid = result.IsValid;
// Get all errors
var allErrors = result.Errors;
// Get errors for specific property
var nameErrors = result.Errors
.Where(e => e.PropertyName == nameof(CreateProductRequest.Name))
.ToList();
// Get first error message
var firstError = result.Errors.FirstOrDefault()?.ErrorMessage;
// Get all error messages
var errorMessages = result.Errors.Select(e => e.ErrorMessage).ToList();
// Get errors as dictionary
var errorDictionary = result.ToDictionary();
}
}
public class CreateProductRequest
{
public string Name { get; set; }
public decimal Price { get; set; }
public string Description { get; set; }
}
---
Built-in Validation Rules
Exercise 3: Explore Built-in Rules
Question: Demonstrate all major built-in validation rules.
Answer
public class BuiltInRulesValidator : AbstractValidator<SampleModel>
{
public BuiltInRulesValidator()
{
// String validations
RuleFor(x => x.RequiredField)
.NotEmpty(); // Not null, empty, or whitespace
RuleFor(x => x.NotNullField)
.NotNull(); // Not null (allows empty string)
RuleFor(x => x.LengthField)
.Length(5, 100); // Between 5 and 100 characters
RuleFor(x => x.MinLengthField)
.MinimumLength(5); // At least 5 characters
RuleFor(x => x.MaxLengthField)
.MaximumLength(100); // At most 100 characters
RuleFor(x => x.RegexField)
.Matches(@"^[a-zA-Z0-9]+$"); // Alphanumeric only
RuleFor(x => x.EmailField)
.EmailAddress(); // Valid email format
// Numeric validations
RuleFor(x => x.GreaterThanField)
.GreaterThan(0); // > 0
RuleFor(x => x.GreaterThanOrEqualField)
.GreaterThanOrEqualTo(0); // >= 0
RuleFor(x => x.LessThanField)
.LessThan(100); // < 100
RuleFor(x => x.LessThanOrEqualField)
.LessThanOrEqualTo(100); // <= 100
RuleFor(x => x.RangeField)
.InclusiveBetween(1, 100); // Between 1 and 100 (inclusive)
RuleFor(x => x.ExclusiveRangeField)
.ExclusiveBetween(0, 100); // Between 0 and 100 (exclusive)
RuleFor(x => x.PrecisionField)
.PrecisionScale(10, 2, false); // Max 10 digits, 2 decimal places
// Comparison validations
RuleFor(x => x.EqualField)
.Equal("Expected Value");
RuleFor(x => x.NotEqualField)
.NotEqual("Forbidden Value");
RuleFor(x => x.ConfirmPassword)
.Equal(x => x.Password).WithMessage("Passwords must match");
// Collection validations
RuleFor(x => x.CollectionField)
.NotEmpty(); // Collection must have at least one item
RuleFor(x => x.MustField)
.Must(BeValidValue).WithMessage("Custom validation failed");
// Enum validations
RuleFor(x => x.EnumField)
.IsInEnum(); // Must be valid enum value
// URL validations
RuleFor(x => x.UrlField)
.Must(BeValidUrl).WithMessage("Invalid URL");
// Credit card validation
RuleFor(x => x.CreditCardField)
.CreditCard(); // Valid credit card number (Luhn algorithm)
// Null validation
RuleFor(x => x.NullableField)
.Null().When(x => x.SomeCondition);
// Empty validation
RuleFor(x => x.EmptyField)
.Empty().When(x => x.SomeCondition);
// Scale precision
RuleFor(x => x.ScalePrecisionField)
.ScalePrecision(2, 5); // Max 5 digits with 2 decimal places
}
private bool BeValidValue(string value)
{
// Custom validation logic
return !string.IsNullOrEmpty(value) && value.Length > 3;
}
private bool BeValidUrl(string url)
{
return Uri.TryCreate(url, UriKind.Absolute, out var uri)
&& (uri.Scheme == Uri.UriSchemeHttp || uri.Scheme == Uri.UriSchemeHttps);
}
}
public class SampleModel
{
public string RequiredField { get; set; }
public string NotNullField { get; set; }
public string LengthField { get; set; }
public string MinLengthField { get; set; }
public string MaxLengthField { get; set; }
public string RegexField { get; set; }
public string EmailField { get; set; }
public int GreaterThanField { get; set; }
public int GreaterThanOrEqualField { get; set; }
public int LessThanField { get; set; }
public int LessThanOrEqualField { get; set; }
public int RangeField { get; set; }
public int ExclusiveRangeField { get; set; }
public decimal PrecisionField { get; set; }
public string EqualField { get; set; }
public string NotEqualField { get; set; }
public string Password { get; set; }
public string ConfirmPassword { get; set; }
public List<string> CollectionField { get; set; }
public string MustField { get; set; }
public Status EnumField { get; set; }
public string UrlField { get; set; }
public string CreditCardField { get; set; }
public string NullableField { get; set; }
public string EmptyField { get; set; }
public decimal ScalePrecisionField { get; set; }
public bool SomeCondition { get; set; }
}
public enum Status
{
Pending,
Active,
Inactive
}
---
Custom Validation Rules
Exercise 4: Create Custom Validators
Question: Implement custom validation rules for complex scenarios.
Answer
// Custom validator using Must
public class CreateOrderRequestValidator : AbstractValidator<CreateOrderRequest>
{
public CreateOrderRequestValidator()
{
RuleFor(x => x.DeliveryDate)
.Must(BeAFutureDate).WithMessage("Delivery date must be in the future")
.Must(BeABusinessDay).WithMessage("Delivery must be on a business day");
RuleFor(x => x.CreditCardNumber)
.Must(BeValidCreditCard).WithMessage("Invalid credit card number");
RuleFor(x => x.PhoneNumber)
.Must(BeValidPhoneNumber).WithMessage("Invalid phone number format");
}
private bool BeAFutureDate(DateTime date)
{
return date.Date > DateTime.Today;
}
private bool BeABusinessDay(DateTime date)
{
return date.DayOfWeek != DayOfWeek.Saturday
&& date.DayOfWeek != DayOfWeek.Sunday;
}
private bool BeValidCreditCard(string cardNumber)
{
// Luhn algorithm implementation
if (string.IsNullOrWhiteSpace(cardNumber))
return false;
cardNumber = cardNumber.Replace(" ", "");
if (!cardNumber.All(char.IsDigit))
return false;
int sum = 0;
bool alternate = false;
for (int i = cardNumber.Length - 1; i >= 0; i--)
{
int digit = cardNumber[i] - '0';
if (alternate)
{
digit *= 2;
if (digit > 9)
digit -= 9;
}
sum += digit;
alternate = !alternate;
}
return sum % 10 == 0;
}
private bool BeValidPhoneNumber(string phoneNumber)
{
if (string.IsNullOrWhiteSpace(phoneNumber))
return false;
// Remove formatting
var cleaned = new string(phoneNumber.Where(char.IsDigit).ToArray());
// US phone number: 10 digits
return cleaned.Length == 10;
}
}
// Custom validator with context
public class PasswordValidator : AbstractValidator<ChangePasswordRequest>
{
public PasswordValidator()
{
RuleFor(x => x.NewPassword)
.Must((request, newPassword) => BeStrongPassword(newPassword))
.WithMessage("Password must contain uppercase, lowercase, digit, and special character");
RuleFor(x => x.NewPassword)
.Must((request, newPassword) => BeDifferentFromOldPassword(request.OldPassword, newPassword))
.WithMessage("New password must be different from old password");
}
private bool BeStrongPassword(string password)
{
if (string.IsNullOrWhiteSpace(password) || password.Length < 8)
return false;
bool hasUpper = password.Any(char.IsUpper);
bool hasLower = password.Any(char.IsLower);
bool hasDigit = password.Any(char.IsDigit);
bool hasSpecial = password.Any(c => !char.IsLetterOrDigit(c));
return hasUpper && hasLower && hasDigit && hasSpecial;
}
private bool BeDifferentFromOldPassword(string oldPassword, string newPassword)
{
return oldPassword != newPassword;
}
}
public class ChangePasswordRequest
{
public string OldPassword { get; set; }
public string NewPassword { get; set; }
}
// Reusable custom validators
public static class CustomValidators
{
public static IRuleBuilderOptions<T, string> MustBeValidUrl<T>(
this IRuleBuilder<T, string> ruleBuilder)
{
return ruleBuilder.Must(url =>
{
if (string.IsNullOrWhiteSpace(url))
return false;
return Uri.TryCreate(url, UriKind.Absolute, out var uri)
&& (uri.Scheme == Uri.UriSchemeHttp || uri.Scheme == Uri.UriSchemeHttps);
}).WithMessage("'{PropertyName}' must be a valid URL");
}
public static IRuleBuilderOptions<T, string> MustBeAlphanumeric<T>(
this IRuleBuilder<T, string> ruleBuilder)
{
return ruleBuilder.Matches(@"^[a-zA-Z0-9]+$")
.WithMessage("'{PropertyName}' must contain only letters and numbers");
}
public static IRuleBuilderOptions<T, DateTime> MustBeInThePast<T>(
this IRuleBuilder<T, DateTime> ruleBuilder)
{
return ruleBuilder.Must(date => date < DateTime.Now)
.WithMessage("'{PropertyName}' must be in the past");
}
public static IRuleBuilderOptions<T, DateTime> MustBeInTheFuture<T>(
this IRuleBuilder<T, DateTime> ruleBuilder)
{
return ruleBuilder.Must(date => date > DateTime.Now)
.WithMessage("'{PropertyName}' must be in the future");
}
}
// Usage of custom validators
public class EventValidator : AbstractValidator<CreateEventRequest>
{
public EventValidator()
{
RuleFor(x => x.EventDate)
.MustBeInTheFuture();
RuleFor(x => x.WebsiteUrl)
.MustBeValidUrl();
RuleFor(x => x.EventCode)
.MustBeAlphanumeric();
}
}
public class CreateEventRequest
{
public DateTime EventDate { get; set; }
public string WebsiteUrl { get; set; }
public string EventCode { get; set; }
}
public class CreateOrderRequest
{
public DateTime DeliveryDate { get; set; }
public string CreditCardNumber { get; set; }
public string PhoneNumber { get; set; }
}
---
Async Validation
Exercise 5: Implement Async Validators
Question: Create validators that perform asynchronous checks (e.g., database lookups).
Answer
// Application/Validators/CreateUserRequestValidator.cs
public class CreateUserRequestValidator : AbstractValidator<CreateUserRequest>
{
private readonly IUserRepository _userRepository;
private readonly IEmailService _emailService;
public CreateUserRequestValidator(
IUserRepository userRepository,
IEmailService emailService)
{
_userRepository = userRepository;
_emailService = emailService;
RuleFor(x => x.Username)
.NotEmpty()
.MustAsync(BeUniqueUsername)
.WithMessage("Username '{PropertyValue}' is already taken");
RuleFor(x => x.Email)
.NotEmpty()
.EmailAddress()
.MustAsync(BeUniqueEmail)
.WithMessage("Email '{PropertyValue}' is already registered")
.MustAsync(BeValidEmailDomain)
.WithMessage("Email domain is not allowed");
RuleFor(x => x.ReferralCode)
.MustAsync(BeValidReferralCode)
.When(x => !string.IsNullOrEmpty(x.ReferralCode))
.WithMessage("Invalid referral code");
}
private async Task<bool> BeUniqueUsername(string username, CancellationToken cancellationToken)
{
var existingUser = await _userRepository.GetByUsernameAsync(username);
return existingUser == null;
}
private async Task<bool> BeUniqueEmail(string email, CancellationToken cancellationToken)
{
var existingUser = await _userRepository.GetByEmailAsync(email);
return existingUser == null;
}
private async Task<bool> BeValidEmailDomain(string email, CancellationToken cancellationToken)
{
var domain = email.Split('@')[1];
var blockedDomains = await _emailService.GetBlockedDomainsAsync();
return !blockedDomains.Contains(domain);
}
private async Task<bool> BeValidReferralCode(string referralCode, CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(referralCode))
return true;
var referral = await _userRepository.GetByReferralCodeAsync(referralCode);
return referral != null && referral.IsActive;
}
}
// More complex async validation with context
public class TransferFundsRequestValidator : AbstractValidator<TransferFundsRequest>
{
private readonly IBankAccountRepository _accountRepository;
private readonly IFraudDetectionService _fraudService;
public TransferFundsRequestValidator(
IBankAccountRepository accountRepository,
IFraudDetectionService fraudService)
{
_accountRepository = accountRepository;
_fraudService = fraudService;
RuleFor(x => x.FromAccountId)
.MustAsync(AccountExists)
.WithMessage("Source account not found");
RuleFor(x => x.ToAccountId)
.MustAsync(AccountExists)
.WithMessage("Destination account not found");
RuleFor(x => x.Amount)
.GreaterThan(0)
.MustAsync(HaveSufficientFunds)
.WithMessage("Insufficient funds")
.MustAsync(NotExceedDailyLimit)
.WithMessage("Amount exceeds daily transfer limit");
RuleFor(x => x)
.MustAsync(NotBeFraudulent)
.WithMessage("Transaction flagged as potentially fraudulent");
}
private async Task<bool> AccountExists(Guid accountId, CancellationToken ct)
{
var account = await _accountRepository.GetByIdAsync(accountId);
return account != null;
}
private async Task<bool> HaveSufficientFunds(
TransferFundsRequest request,
decimal amount,
CancellationToken ct)
{
var account = await _accountRepository.GetByIdAsync(request.FromAccountId);
return account != null && account.Balance >= amount;
}
private async Task<bool> NotExceedDailyLimit(
TransferFundsRequest request,
decimal amount,
CancellationToken ct)
{
var todayTransfers = await _accountRepository.GetTodayTransfersAsync(request.FromAccountId);
var totalToday = todayTransfers.Sum(t => t.Amount);
return (totalToday + amount) <= 10000m; // $10,000 daily limit
}
private async Task<bool> NotBeFraudulent(
TransferFundsRequest request,
CancellationToken ct)
{
var fraudCheck = await _fraudService.CheckTransactionAsync(
request.FromAccountId,
request.ToAccountId,
request.Amount
);
return !fraudCheck.IsFraudulent;
}
}
public class CreateUserRequest
{
public string Username { get; set; }
public string Email { get; set; }
public string ReferralCode { get; set; }
}
public class TransferFundsRequest
{
public Guid FromAccountId { get; set; }
public Guid ToAccountId { get; set; }
public decimal Amount { get; set; }
}
// Usage
public class UserService
{
private readonly IValidator<CreateUserRequest> _validator;
public async Task<Result> CreateUserAsync(CreateUserRequest request)
{
// Async validation
var validationResult = await _validator.ValidateAsync(request);
if (!validationResult.IsValid)
{
return Result.Failure(validationResult.Errors);
}
// Process user creation
return Result.Success();
}
}
Key Points:
- Use
MustAsyncfor async validation rules - Always accept
CancellationTokenparameter - Async validators support dependency injection
- Can access entire object or individual properties
- Be mindful of performance with multiple async calls
---
Dependent Rules (When/Unless)
Exercise 6: Conditional Validation with When/Unless
Question: Implement conditional validation that depends on other property values.
Answer
public class OrderRequestValidator : AbstractValidator<OrderRequest>
{
public OrderRequestValidator()
{
// Basic required fields
RuleFor(x => x.CustomerName)
.NotEmpty();
// Shipping address required when delivery type is "Delivery"
RuleFor(x => x.ShippingAddress)
.NotEmpty()
.When(x => x.DeliveryType == DeliveryType.Delivery)
.WithMessage("Shipping address is required for delivery orders");
// Shipping address NOT required when pickup
RuleFor(x => x.ShippingAddress)
.Empty()
.Unless(x => x.DeliveryType == DeliveryType.Delivery)
.WithMessage("Shipping address should not be provided for pickup orders");
// Gift message only when IsGift is true
RuleFor(x => x.GiftMessage)
.NotEmpty()
.When(x => x.IsGift)
.WithMessage("Gift message is required when order is marked as gift");
RuleFor(x => x.GiftMessage)
.MaximumLength(200)
.When(x => x.IsGift);
// Business rules based on payment method
RuleFor(x => x.CreditCardNumber)
.NotEmpty()
.CreditCard()
.When(x => x.PaymentMethod == PaymentMethod.CreditCard)
.WithMessage("Valid credit card number required");
RuleFor(x => x.CreditCardExpiry)
.NotEmpty()
.When(x => x.PaymentMethod == PaymentMethod.CreditCard);
RuleFor(x => x.CreditCardCVV)
.NotEmpty()
.Length(3, 4)
.When(x => x.PaymentMethod == PaymentMethod.CreditCard);
// PayPal email required for PayPal payments
RuleFor(x => x.PayPalEmail)
.NotEmpty()
.EmailAddress()
.When(x => x.PaymentMethod == PaymentMethod.PayPal);
// Discount code validation
RuleFor(x => x.DiscountCode)
.NotEmpty()
.When(x => x.ApplyDiscount)
.WithMessage("Discount code required when applying discount");
// Age verification for restricted products
RuleFor(x => x.DateOfBirth)
.NotEmpty()
.When(x => x.ContainsRestrictedItems)
.WithMessage("Date of birth required for age verification");
RuleFor(x => x.DateOfBirth)
.Must(BeAtLeast18YearsOld)
.When(x => x.ContainsRestrictedItems)
.WithMessage("Must be at least 18 years old");
// Complex When condition
RuleFor(x => x.CompanyName)
.NotEmpty()
.When(x => x.CustomerType == CustomerType.Business && x.OrderTotal > 1000)
.WithMessage("Company name required for business orders over $1000");
// Unless condition (opposite of When)
RuleFor(x => x.Email)
.NotEmpty()
.Unless(x => x.IsAnonymous)
.WithMessage("Email required for registered users");
// Chaining When conditions
RuleFor(x => x.TaxId)
.NotEmpty()
.When(x => x.CustomerType == CustomerType.Business)
.When(x => x.Country == "US")
.WithMessage("Tax ID required for US business customers");
}
private bool BeAtLeast18YearsOld(DateTime dateOfBirth)
{
var age = DateTime.Today.Year - dateOfBirth.Year;
if (dateOfBirth.Date > DateTime.Today.AddYears(-age))
age--;
return age >= 18;
}
}
// More complex conditional validation
public class EmployeeValidator : AbstractValidator<Employee>
{
public EmployeeValidator()
{
// Manager-specific validations
When(x => x.IsManager, () =>
{
RuleFor(x => x.Department)
.NotEmpty()
.WithMessage("Department is required for managers");
RuleFor(x => x.ManagementLevel)
.IsInEnum()
.WithMessage("Valid management level required");
RuleFor(x => x.DirectReports)
.NotEmpty()
.WithMessage("Managers must have at least one direct report");
});
// Non-manager validations
Unless(x => x.IsManager, () =>
{
RuleFor(x => x.ManagerId)
.NotEmpty()
.WithMessage("Manager assignment required for non-managers");
});
// Part-time employee rules
When(x => x.EmploymentType == EmploymentType.PartTime, () =>
{
RuleFor(x => x.HoursPerWeek)
.LessThan(40)
.WithMessage("Part-time employees must work less than 40 hours");
});
// Full-time employee rules
When(x => x.EmploymentType == EmploymentType.FullTime, () =>
{
RuleFor(x => x.BenefitsPackage)
.NotEmpty()
.WithMessage("Benefits package required for full-time employees");
});
}
}
public class OrderRequest
{
public string CustomerName { get; set; }
public DeliveryType DeliveryType { get; set; }
public string ShippingAddress { get; set; }
public bool IsGift { get; set; }
public string GiftMessage { get; set; }
public PaymentMethod PaymentMethod { get; set; }
public string CreditCardNumber { get; set; }
public string CreditCardExpiry { get; set; }
public string CreditCardCVV { get; set; }
public string PayPalEmail { get; set; }
public bool ApplyDiscount { get; set; }
public string DiscountCode { get; set; }
public bool ContainsRestrictedItems { get; set; }
public DateTime? DateOfBirth { get; set; }
public CustomerType CustomerType { get; set; }
public decimal OrderTotal { get; set; }
public string CompanyName { get; set; }
public bool IsAnonymous { get; set; }
public string Email { get; set; }
public string Country { get; set; }
public string TaxId { get; set; }
}
public enum DeliveryType { Delivery, Pickup }
public enum PaymentMethod { CreditCard, PayPal, Cash }
public enum CustomerType { Individual, Business }
public class Employee
{
public bool IsManager { get; set; }
public string Department { get; set; }
public int ManagementLevel { get; set; }
public List<Guid> DirectReports { get; set; }
public Guid? ManagerId { get; set; }
public EmploymentType EmploymentType { get; set; }
public int HoursPerWeek { get; set; }
public string BenefitsPackage { get; set; }
}
public enum EmploymentType { FullTime, PartTime, Contract }
Key Concepts:
When(predicate, action): Apply rules only when condition is trueUnless(predicate, action): Apply rules only when condition is false- Can chain multiple
Whenconditions - Can group multiple rules inside
Whenblock - Useful for complex business rules
---
Nested Validators
Exercise 7: Validate Complex Nested Objects
Question: Create validators for objects that contain nested complex objects.
Answer
// Domain models
public class CreateOrderRequest
{
public Guid CustomerId { get; set; }
public Address ShippingAddress { get; set; }
public Address BillingAddress { get; set; }
public List<OrderItem> Items { get; set; }
public PaymentInfo Payment { get; set; }
}
public class Address
{
public string Street { get; set; }
public string City { get; set; }
public string State { get; set; }
public string ZipCode { get; set; }
public string Country { get; set; }
}
public class OrderItem
{
public Guid ProductId { get; set; }
public int Quantity { get; set; }
public decimal UnitPrice { get; set; }
}
public class PaymentInfo
{
public PaymentMethod Method { get; set; }
public CreditCardInfo CreditCard { get; set; }
}
public class CreditCardInfo
{
public string CardNumber { get; set; }
public string CardholderName { get; set; }
public string ExpiryDate { get; set; }
public string CVV { get; set; }
}
// Validators for nested objects
public class AddressValidator : AbstractValidator<Address>
{
public AddressValidator()
{
RuleFor(x => x.Street)
.NotEmpty()
.MaximumLength(200);
RuleFor(x => x.City)
.NotEmpty()
.MaximumLength(100);
RuleFor(x => x.State)
.NotEmpty()
.Length(2)
.WithMessage("State must be 2-letter code");
RuleFor(x => x.ZipCode)
.NotEmpty()
.Matches(@"^\d{5}(-\d{4})?$")
.WithMessage("Invalid ZIP code format");
RuleFor(x => x.Country)
.NotEmpty()
.Must(BeValidCountryCode)
.WithMessage("Invalid country code");
}
private bool BeValidCountryCode(string country)
{
var validCodes = new[] { "US", "CA", "UK", "AU" };
return validCodes.Contains(country);
}
}
public class OrderItemValidator : AbstractValidator<OrderItem>
{
public OrderItemValidator()
{
RuleFor(x => x.ProductId)
.NotEmpty();
RuleFor(x => x.Quantity)
.GreaterThan(0)
.LessThanOrEqualTo(100)
.WithMessage("Quantity must be between 1 and 100");
RuleFor(x => x.UnitPrice)
.GreaterThan(0)
.WithMessage("Unit price must be positive");
}
}
public class CreditCardInfoValidator : AbstractValidator<CreditCardInfo>
{
public CreditCardInfoValidator()
{
RuleFor(x => x.CardNumber)
.NotEmpty()
.CreditCard()
.WithMessage("Invalid credit card number");
RuleFor(x => x.CardholderName)
.NotEmpty()
.MaximumLength(100);
RuleFor(x => x.ExpiryDate)
.NotEmpty()
.Matches(@"^(0[1-9]|1[0-2])\/\d{2}$")
.WithMessage("Expiry date must be in MM/YY format")
.Must(BeInTheFuture)
.WithMessage("Card has expired");
RuleFor(x => x.CVV)
.NotEmpty()
.Matches(@"^\d{3,4}$")
.WithMessage("CVV must be 3 or 4 digits");
}
private bool BeInTheFuture(string expiryDate)
{
if (string.IsNullOrEmpty(expiryDate) || !expiryDate.Contains("/"))
return false;
var parts = expiryDate.Split('/');
if (parts.Length != 2)
return false;
if (!int.TryParse(parts[0], out var month) || !int.TryParse(parts[1], out var year))
return false;
year += 2000; // Convert YY to YYYY
var expiry = new DateTime(year, month, 1).AddMonths(1).AddDays(-1);
return expiry >= DateTime.Today;
}
}
public class PaymentInfoValidator : AbstractValidator<PaymentInfo>
{
public PaymentInfoValidator()
{
RuleFor(x => x.Method)
.IsInEnum();
// Nested validator for credit card
RuleFor(x => x.CreditCard)
.SetValidator(new CreditCardInfoValidator())
.When(x => x.Method == PaymentMethod.CreditCard);
}
}
// Main validator with nested validators
public class CreateOrderRequestValidator : AbstractValidator<CreateOrderRequest>
{
public CreateOrderRequestValidator()
{
RuleFor(x => x.CustomerId)
.NotEmpty();
// Use SetValidator for nested objects
RuleFor(x => x.ShippingAddress)
.NotNull()
.SetValidator(new AddressValidator());
RuleFor(x => x.BillingAddress)
.NotNull()
.SetValidator(new AddressValidator());
// Validate collection of nested objects
RuleFor(x => x.Items)
.NotEmpty()
.WithMessage("Order must contain at least one item");
RuleForEach(x => x.Items)
.SetValidator(new OrderItemValidator());
// Nested payment validator
RuleFor(x => x.Payment)
.NotNull()
.SetValidator(new PaymentInfoValidator());
}
}
// Alternative: Inline nested validation
public class InlineNestedValidator : AbstractValidator<CreateOrderRequest>
{
public InlineNestedValidator()
{
RuleFor(x => x.ShippingAddress)
.NotNull()
.ChildRules(address =>
{
address.RuleFor(a => a.Street).NotEmpty();
address.RuleFor(a => a.City).NotEmpty();
address.RuleFor(a => a.State).Length(2);
address.RuleFor(a => a.ZipCode).Matches(@"^\d{5}$");
});
}
}
// Usage
public class OrderService
{
private readonly IValidator<CreateOrderRequest> _validator;
public async Task<Result> CreateOrderAsync(CreateOrderRequest request)
{
var validationResult = await _validator.ValidateAsync(request);
if (!validationResult.IsValid)
{
// Errors include nested property paths
foreach (var error in validationResult.Errors)
{
Console.WriteLine($"{error.PropertyName}: {error.ErrorMessage}");
// Examples:
// ShippingAddress.Street: 'Street' must not be empty
// Items[0].Quantity: Quantity must be between 1 and 100
// Payment.CreditCard.CardNumber: Invalid credit card number
}
return Result.Failure(validationResult.Errors);
}
return Result.Success();
}
}
Key Points:
- Use
SetValidator()for nested object validation - Use
RuleForEach()withSetValidator()for collections - Use
ChildRules()for inline nested validation - Property paths include nested hierarchy (e.g.,
Address.Street) - Can reuse validators across different parent validators
---
Collection Validation
Exercise 8: Validate Collections
Question: Demonstrate various techniques for validating collections.
Answer
public class PlaylistValidator : AbstractValidator<Playlist>
{
public PlaylistValidator()
{
// Collection must not be empty
RuleFor(x => x.Songs)
.NotEmpty()
.WithMessage("Playlist must contain at least one song");
// Collection size constraints
RuleFor(x => x.Songs)
.Must(songs => songs.Count <= 100)
.WithMessage("Playlist cannot contain more than 100 songs");
// Validate each item in collection
RuleForEach(x => x.Songs)
.SetValidator(new SongValidator());
// Validate each item with index
RuleForEach(x => x.Songs)
.ChildRules(song =>
{
song.RuleFor(s => s.Title).NotEmpty();
song.RuleFor(s => s.Duration).GreaterThan(0);
});
// Complex collection validation
RuleFor(x => x.Songs)
.Must(HaveUniqueIds)
.WithMessage("Playlist cannot contain duplicate songs");
RuleFor(x => x.Songs)
.Must(NotExceedTotalDuration)
.WithMessage("Total playlist duration cannot exceed 10 hours");
// Validate collection based on another property
RuleFor(x => x.Songs)
.Must((playlist, songs) => songs.Count >= playlist.MinSongs)
.WithMessage("Playlist must contain at least {MinSongs} songs");
}
private bool HaveUniqueIds(List<Song> songs)
{
var uniqueIds = songs.Select(s => s.Id).Distinct().Count();
return uniqueIds == songs.Count;
}
private bool NotExceedTotalDuration(List<Song> songs)
{
var totalDuration = songs.Sum(s => s.Duration);
return totalDuration <= TimeSpan.FromHours(10).TotalSeconds;
}
}
public class SongValidator : AbstractValidator<Song>
{
public SongValidator()
{
RuleFor(x => x.Title)
.NotEmpty()
.MaximumLength(200);
RuleFor(x => x.Artist)
.NotEmpty()
.MaximumLength(100);
RuleFor(x => x.Duration)
.GreaterThan(0)
.LessThan(3600); // Max 1 hour
}
}
// More complex collection scenarios
public class ShoppingCartValidator : AbstractValidator<ShoppingCart>
{
private readonly IProductRepository _productRepository;
public ShoppingCartValidator(IProductRepository productRepository)
{
_productRepository = productRepository;
// Basic collection validation
RuleFor(x => x.Items)
.NotEmpty()
.WithMessage("Shopping cart cannot be empty");
// Validate each item
RuleForEach(x => x.Items)
.SetValidator(new CartItemValidator());
// Async collection validation
RuleFor(x => x.Items)
.MustAsync(AllProductsExist)
.WithMessage("One or more products no longer exist");
RuleFor(x => x.Items)
.MustAsync(AllProductsInStock)
.WithMessage("One or more products are out of stock");
// Cross-item validation
RuleFor(x => x.Items)
.Must(NotContainIncompatibleProducts)
.WithMessage("Cart contains incompatible products");
// Total validation
RuleFor(x => x.Items)
.Must(items => items.Sum(i => i.Quantity * i.UnitPrice) <= 10000)
.WithMessage("Cart total cannot exceed $10,000");
}
private async Task<bool> AllProductsExist(List<CartItem> items, CancellationToken ct)
{
var productIds = items.Select(i => i.ProductId).ToList();
var products = await _productRepository.GetByIdsAsync(productIds);
return products.Count == productIds.Count;
}
private async Task<bool> AllProductsInStock(List<CartItem> items, CancellationToken ct)
{
foreach (var item in items)
{
var product = await _productRepository.GetByIdAsync(item.ProductId);
if (product == null || product.StockQuantity < item.Quantity)
return false;
}
return true;
}
private bool NotContainIncompatibleProducts(List<CartItem> items)
{
// Business rule: Can't mix digital and physical products
var hasDigital = items.Any(i => i.IsDigital);
var hasPhysical = items.Any(i => !i.IsDigital);
return !(hasDigital && hasPhysical);
}
}
public class CartItemValidator : AbstractValidator<CartItem>
{
public CartItemValidator()
{
RuleFor(x => x.ProductId)
.NotEmpty();
RuleFor(x => x.Quantity)
.GreaterThan(0)
.LessThanOrEqualTo(100);
RuleFor(x => x.UnitPrice)
.GreaterThan(0);
}
}
// Collection with conditional validation
public class BatchOrderValidator : AbstractValidator<BatchOrder>
{
public BatchOrderValidator()
{
RuleFor(x => x.Orders)
.NotEmpty()
.WithMessage("Batch must contain at least one order");
RuleFor(x => x.Orders)
.Must(orders => orders.Count <= 1000)
.WithMessage("Batch cannot contain more than 1000 orders");
// Different validation for different order types
RuleForEach(x => x.Orders)
.SetValidator(order => new OrderValidator())
.When(order => order.OrderType == OrderType.Standard);
RuleForEach(x => x.Orders)
.SetValidator(order => new ExpressOrderValidator())
.When(order => order.OrderType == OrderType.Express);
}
}
public class Playlist
{
public string Name { get; set; }
public List<Song> Songs { get; set; }
public int MinSongs { get; set; }
}
public class Song
{
public Guid Id { get; set; }
public string Title { get; set; }
public string Artist { get; set; }
public int Duration { get; set; }
}
public class ShoppingCart
{
public Guid Id { get; set; }
public List<CartItem> Items { get; set; }
}
public class CartItem
{
public Guid ProductId { get; set; }
public int Quantity { get; set; }
public decimal UnitPrice { get; set; }
public bool IsDigital { get; set; }
}
public class BatchOrder
{
public List<Order> Orders { get; set; }
}
public class Order
{
public OrderType OrderType { get; set; }
}
public enum OrderType { Standard, Express }
---
RuleSet Usage
Exercise 9: Use RuleSets for Different Scenarios
Question: Implement RuleSets to apply different validation rules for create vs update scenarios.
Answer
public class ProductValidator : AbstractValidator<Product>
{
public ProductValidator()
{
// Default rules (always applied)
RuleFor(x => x.Name)
.NotEmpty()
.MaximumLength(200);
RuleFor(x => x.Price)
.GreaterThan(0);
// Create-specific rules
RuleSet("Create", () =>
{
RuleFor(x => x.Id)
.Empty()
.WithMessage("ID must not be provided for new products");
RuleFor(x => x.SKU)
.NotEmpty()
.WithMessage("SKU is required when creating a product");
RuleFor(x => x.Category)
.NotEmpty()
.WithMessage("Category is required for new products");
});
// Update-specific rules
RuleSet("Update", () =>
{
RuleFor(x => x.Id)
.NotEmpty()
.WithMessage("ID is required for updates");
RuleFor(x => x.SKU)
.Empty()
.WithMessage("SKU cannot be changed");
});
// Delete-specific rules
RuleSet("Delete", () =>
{
RuleFor(x => x.Id)
.NotEmpty();
RuleFor(x => x.HasActiveOrders)
.Equal(false)
.WithMessage("Cannot delete product with active orders");
});
// Publish-specific rules
RuleSet("Publish", () =>
{
RuleFor(x => x.Description)
.NotEmpty()
.MinimumLength(100)
.WithMessage("Description must be at least 100 characters for published products");
RuleFor(x => x.Images)
.NotEmpty()
.WithMessage("At least one image required for published products");
RuleFor(x => x.StockQuantity)
.GreaterThan(0)
.WithMessage("Cannot publish product with zero stock");
});
// Multiple RuleSets
RuleSet("Create, Update", () =>
{
// Rules for both create and update
RuleFor(x => x.Price)
.LessThan(100000)
.WithMessage("Price cannot exceed $100,000");
});
}
}
// Usage in service
public class ProductService
{
private readonly IValidator<Product> _validator;
// Create
public async Task<Result> CreateProductAsync(Product product)
{
var result = await _validator.ValidateAsync(product, options =>
{
options.IncludeRuleSets("Create");
options.IncludeDefaultRules(); // Include default rules too
});
if (!result.IsValid)
return Result.Failure(result.Errors);
// Save product
return Result.Success();
}
// Update
public async Task<Result> UpdateProductAsync(Product product)
{
var result = await _validator.ValidateAsync(product, options =>
{
options.IncludeRuleSets("Update");
});
if (!result.IsValid)
return Result.Failure(result.Errors);
// Update product
return Result.Success();
}
// Delete
public async Task<Result> DeleteProductAsync(Product product)
{
var result = await _validator.ValidateAsync(product, options =>
{
options.IncludeRuleSets("Delete");
});
if (!result.IsValid)
return Result.Failure(result.Errors);
// Delete product
return Result.Success();
}
// Publish
public async Task<Result> PublishProductAsync(Product product)
{
var result = await _validator.ValidateAsync(product, options =>
{
options.IncludeRuleSets("Publish");
options.IncludeDefaultRules();
});
if (!result.IsValid)
return Result.Failure(result.Errors);
product.IsPublished = true;
return Result.Success();
}
}
// More complex RuleSet example
public class UserValidator : AbstractValidator<User>
{
public UserValidator()
{
// Always required
RuleFor(x => x.Email)
.NotEmpty()
.EmailAddress();
// Registration rules
RuleSet("Register", () =>
{
RuleFor(x => x.Password)
.NotEmpty()
.MinimumLength(8)
.Matches(@"[A-Z]").WithMessage("Password must contain uppercase")
.Matches(@"[a-z]").WithMessage("Password must contain lowercase")
.Matches(@"[0-9]").WithMessage("Password must contain digit");
RuleFor(x => x.ConfirmPassword)
.Equal(x => x.Password);
RuleFor(x => x.AcceptedTerms)
.Equal(true)
.WithMessage("Must accept terms and conditions");
});
// Profile update rules
RuleSet("ProfileUpdate", () =>
{
RuleFor(x => x.FirstName)
.NotEmpty()
.MaximumLength(50);
RuleFor(x => x.LastName)
.NotEmpty()
.MaximumLength(50);
RuleFor(x => x.Phone)
.Matches(@"^\d{10}$")
.When(x => !string.IsNullOrEmpty(x.Phone));
});
// Change password rules
RuleSet("ChangePassword", () =>
{
RuleFor(x => x.CurrentPassword)
.NotEmpty();
RuleFor(x => x.NewPassword)
.NotEmpty()
.MinimumLength(8)
.NotEqual(x => x.CurrentPassword)
.WithMessage("New password must be different from current password");
});
// Admin-only rules
RuleSet("AdminUpdate", () =>
{
RuleFor(x => x.Role)
.NotEmpty()
.IsInEnum();
RuleFor(x => x.IsActive)
.NotNull();
});
}
}
public class Product
{
public Guid Id { get; set; }
public string Name { get; set; }
public decimal Price { get; set; }
public string SKU { get; set; }
public string Category { get; set; }
public string Description { get; set; }
public List<string> Images { get; set; }
public int StockQuantity { get; set; }
public bool HasActiveOrders { get; set; }
public bool IsPublished { get; set; }
}
public class User
{
public string Email { get; set; }
public string Password { get; set; }
public string ConfirmPassword { get; set; }
public string CurrentPassword { get; set; }
public string NewPassword { get; set; }
public bool AcceptedTerms { get; set; }
public string FirstName { get; set; }
public string LastName { get; set; }
public string Phone { get; set; }
public string Role { get; set; }
public bool IsActive { get; set; }
}
RuleSet Benefits:
- Separate validation logic for different scenarios
- Avoid creating multiple validators for same entity
- Can combine multiple RuleSets
- Can include or exclude default rules
- Cleaner code organization
---
Integration with ASP.NET Core
Exercise 10: Integrate FluentValidation with ASP.NET Core
Question: Set up automatic validation in ASP.NET Core controllers.
Answer
// Program.cs
var builder = WebApplication.CreateBuilder(args);
// Add controllers
builder.Services.AddControllers();
// Add FluentValidation
builder.Services.AddFluentValidationAutoValidation();
builder.Services.AddFluentValidationClientsideAdapters();
builder.Services.AddValidatorsFromAssemblyContaining<Program>();
// Optional: Configure validation behavior
builder.Services.Configure<ApiBehaviorOptions>(options =>
{
// Customize validation error response
options.InvalidModelStateResponseFactory = context =>
{
var errors = context.ModelState
.Where(e => e.Value.Errors.Count > 0)
.Select(e => new
{
Field = e.Key,
Errors = e.Value.Errors.Select(x => x.ErrorMessage).ToArray()
});
return new BadRequestObjectResult(new
{
Message = "Validation failed",
Errors = errors
});
};
});
var app = builder.Build();
// Controllers with automatic validation
[ApiController]
[Route("api/[controller]")]
public class ProductsController : ControllerBase
{
private readonly IProductService _productService;
public ProductsController(IProductService productService)
{
_productService = productService;
}
[HttpPost]
public async Task<IActionResult> CreateProduct([FromBody] CreateProductRequest request)
{
// Validation happens automatically before this code runs
// If validation fails, BadRequest is returned automatically
var result = await _productService.CreateProductAsync(request);
return Ok(result);
}
[HttpPut("{id}")]
public async Task<IActionResult> UpdateProduct(
Guid id,
[FromBody] UpdateProductRequest request)
{
// Automatic validation
var result = await _productService.UpdateProductAsync(id, request);
return Ok(result);
}
}
// Manual validation in controller
[ApiController]
[Route("api/[controller]")]
public class OrdersController : ControllerBase
{
private readonly IValidator<CreateOrderRequest> _validator;
private readonly IOrderService _orderService;
public OrdersController(
IValidator<CreateOrderRequest> validator,
IOrderService orderService)
{
_validator = validator;
_orderService = orderService;
}
[HttpPost]
public async Task<IActionResult> CreateOrder([FromBody] CreateOrderRequest request)
{
// Manual validation
var validationResult = await _validator.ValidateAsync(request);
if (!validationResult.IsValid)
{
// Add errors to ModelState
foreach (var error in validationResult.Errors)
{
ModelState.AddModelError(error.PropertyName, error.ErrorMessage);
}
return BadRequest(ModelState);
}
var result = await _orderService.CreateOrderAsync(request);
return Ok(result);
}
[HttpPost("batch")]
public async Task<IActionResult> CreateBatchOrder([FromBody] CreateOrderRequest request)
{
// Manual validation with RuleSet
var validationResult = await _validator.ValidateAsync(request, options =>
{
options.IncludeRuleSets("Batch");
});
if (!validationResult.IsValid)
{
return BadRequest(validationResult.ToDictionary());
}
var result = await _orderService.CreateBatchOrderAsync(request);
return Ok(result);
}
}
// Custom validation filter
public class ValidateModelAttribute : ActionFilterAttribute
{
public override void OnActionExecuting(ActionExecutingContext context)
{
if (!context.ModelState.IsValid)
{
var errors = context.ModelState
.Where(e => e.Value.Errors.Count > 0)
.ToDictionary(
e => e.Key,
e => e.Value.Errors.Select(x => x.ErrorMessage).ToArray()
);
context.Result = new BadRequestObjectResult(new
{
Message = "Validation failed",
Errors = errors
});
}
}
}
// Use custom filter
[ApiController]
[Route("api/[controller]")]
[ValidateModel] // Apply to all actions
public class CustomersController : ControllerBase
{
[HttpPost]
public IActionResult CreateCustomer([FromBody] CreateCustomerRequest request)
{
// Validation handled by filter
return Ok();
}
}
// Validators
public class CreateProductRequestValidator : AbstractValidator<CreateProductRequest>
{
public CreateProductRequestValidator()
{
RuleFor(x => x.Name)
.NotEmpty()
.MaximumLength(200);
RuleFor(x => x.Price)
.GreaterThan(0)
.WithMessage("Price must be greater than zero");
RuleFor(x => x.Category)
.NotEmpty();
}
}
public class UpdateProductRequestValidator : AbstractValidator<UpdateProductRequest>
{
public UpdateProductRequestValidator()
{
RuleFor(x => x.Name)
.NotEmpty()
.When(x => !string.IsNullOrEmpty(x.Name));
RuleFor(x => x.Price)
.GreaterThan(0)
.When(x => x.Price.HasValue);
}
}
// DTOs
public class CreateProductRequest
{
public string Name { get; set; }
public decimal Price { get; set; }
public string Category { get; set; }
public string Description { get; set; }
}
public class UpdateProductRequest
{
public string Name { get; set; }
public decimal? Price { get; set; }
public string Description { get; set; }
}
public class CreateOrderRequest
{
public Guid CustomerId { get; set; }
public List<OrderItem> Items { get; set; }
}
public class CreateCustomerRequest
{
public string FirstName { get; set; }
public string LastName { get; set; }
public string Email { get; set; }
}
Integration Features:
- Automatic validation before controller action
- Errors automatically added to ModelState
- Can customize error response format
- Can use RuleSets for different endpoints
- Can create custom validation filters
- Client-side validation support
---
Advanced Rule Composition
Exercise 11: CascadeMode Stop
Question: Stop validation on the first failure for a property.
Answer
RuleFor(x => x.Email)
.Cascade(CascadeMode.Stop)
.NotEmpty()
.EmailAddress();
---
Exercise 12: Custom Must Rule
Question: Validate that an order total matches line items.
Answer
RuleFor(x => x)
.Must(order => order.Lines.Sum(l => l.Total) == order.Total)
.WithMessage("Order total does not match line items");
---
Exercise 13: Cross-Property Validation
Question: Require that StartDate is before EndDate.
Answer
RuleFor(x => x.StartDate)
.LessThan(x => x.EndDate)
.WithMessage("StartDate must be before EndDate");
---
Exercise 14: RuleForEach with Child Validator
Question: Validate each order item with a nested validator.
Answer
RuleForEach(x => x.Items).SetValidator(new OrderItemValidator());
---
Exercise 15: Custom Property Validator
Question: Create a reusable validator for ISO currency codes.
Answer
RuleFor(x => x.Currency)
.Must(code => IsoCurrencies.All.Contains(code))
.WithMessage("Invalid currency code");
---
Exercise 16: Async Uniqueness Check
Question: Validate that a username is unique in the database.
Answer
RuleFor(x => x.Username)
.MustAsync(async (name, ct) => !await _repo.ExistsAsync(name, ct))
.WithMessage("Username already exists");
---
Exercise 17: DependentRules
Question: Validate card details only when payment method is card.
Answer
RuleFor(x => x.PaymentMethod)
.Equal("Card")
.DependentRules(() =>
{
RuleFor(x => x.CardNumber).CreditCard();
RuleFor(x => x.Cvv).Length(3, 4);
});
---
Exercise 18: When/Unless Conditions
Question: Validate LimitPrice only for limit orders.
Answer
When(x => x.Type == OrderType.Limit, () =>
{
RuleFor(x => x.LimitPrice).GreaterThan(0);
});
---
Exercise 19: RuleSets for Create vs Update
Question: Use different rules for create and update.
Answer
RuleSet("Create", () =>
{
RuleFor(x => x.Name).NotEmpty();
});
RuleSet("Update", () =>
{
RuleFor(x => x.Id).NotEmpty();
});
---
Exercise 20: Include Other Validators
Question: Reuse shared rules across validators.
Answer
Include(new AddressValidator());
---
Exercise 21: ValidationContext Root Data
Question: Pass contextual data into the validator.
Answer
RuleFor(x => x.Symbol)
.Must((root, symbol, context) =>
{
var allowed = (HashSet<string>)context.RootContextData["allowed"];
return allowed.Contains(symbol);
});
---
Exercise 22: Severity and Error Codes
Question: Tag validation errors with codes and severity.
Answer
RuleFor(x => x.Price)
.GreaterThan(0)
.WithErrorCode("PRICE_001")
.WithSeverity(Severity.Error);
---
Exercise 23: Localized Messages
Question: Localize messages using resources.
Answer
RuleFor(x => x.Email)
.NotEmpty()
.WithMessage(Resources.Messages.EmailRequired);
---
Exercise 24: Polymorphic Validation
Question: Validate different DTO subtypes with SetInheritanceValidator.
Answer
RuleFor(x => x.Order)
.SetInheritanceValidator(v =>
{
v.Add<LimitOrderDto>(new LimitOrderValidator());
v.Add<MarketOrderDto>(new MarketOrderValidator());
});
---
Exercise 25: Validate Partial Updates
Question: Validate only provided properties in a PATCH request.
Answer
public void ValidatePatch(UpdateUserDto dto)
{
_validator.Validate(dto, options => options.IncludeProperties("Email", "Phone"));
}
---
Exercise 26: Unique Items in a Collection
Question: Ensure a collection has unique symbols.
Answer
RuleFor(x => x.Symbols)
.Must(list => list.Distinct().Count() == list.Count)
.WithMessage("Symbols must be unique");
---
Exercise 27: Transform for Normalization
Question: Normalize input before validation.
Answer
RuleFor(x => x.Email)
.Transform(email => email?.Trim().ToLowerInvariant())
.EmailAddress();
---
Exercise 28: Custom Validation Block
Question: Use Custom for multi-property checks.
Answer
RuleFor(x => x).Custom((model, context) =>
{
if (model.Min > model.Max)
context.AddFailure("Min", "Min must be <= Max");
});
---
Exercise 29: MediatR Pipeline Integration
Question: Validate commands with a pipeline behavior.
Answer
Register ValidationBehavior and throw a domain-specific exception on failure.
---
Validator Testing & Diagnostics
Exercise 30: Test Helper Usage
Question: Write a test that asserts a validation error.
Answer
var validator = new RegisterUserRequestValidator();
var result = validator.TestValidate(new RegisterUserRequest { Email = "bad" });
result.ShouldHaveValidationErrorFor(x => x.Email);